diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/(main)/websites/[websiteId]/events | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/events')
6 files changed, 393 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx new file mode 100644 index 0000000..c3b1325 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx @@ -0,0 +1,127 @@ +import { Column, Grid, ListItem, Select } from '@umami/react-zen'; +import { useMemo, useState } from 'react'; +import { PieChart } from '@/components/charts/PieChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { + useEventDataPropertiesQuery, + useEventDataValuesQuery, + useMessages, +} from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { CHART_COLORS } from '@/lib/constants'; + +export function EventProperties({ websiteId }: { websiteId: string }) { + const [propertyName, setPropertyName] = useState(''); + const [eventName, setEventName] = useState(''); + + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useEventDataPropertiesQuery(websiteId); + + const events: string[] = data + ? data.reduce((arr: string | any[], e: { eventName: any }) => { + return !arr.includes(e.eventName) ? arr.concat(e.eventName) : arr; + }, []) + : []; + const properties: string[] = eventName + ? data?.filter(e => e.eventName === eventName).map(e => e.propertyName) + : []; + + return ( + <LoadingPanel + data={data} + isLoading={isLoading} + isFetching={isFetching} + error={error} + minHeight="300px" + > + <Column gap="6"> + {data && ( + <Grid columns="repeat(auto-fill, minmax(300px, 1fr))" marginBottom="3" gap> + <Select + label={formatMessage(labels.event)} + value={eventName} + onChange={setEventName} + placeholder="" + > + {events?.map(p => ( + <ListItem key={p} id={p}> + {p} + </ListItem> + ))} + </Select> + <Select + label={formatMessage(labels.property)} + value={propertyName} + onChange={setPropertyName} + isDisabled={!eventName} + placeholder="" + > + {properties?.map(p => ( + <ListItem key={p} id={p}> + {p} + </ListItem> + ))} + </Select> + </Grid> + )} + {eventName && propertyName && ( + <EventValues websiteId={websiteId} eventName={eventName} propertyName={propertyName} /> + )} + </Column> + </LoadingPanel> + ); +} + +const EventValues = ({ websiteId, eventName, propertyName }) => { + const { + data: values, + isLoading, + isFetching, + error, + } = useEventDataValuesQuery(websiteId, eventName, propertyName); + + const propertySum = useMemo(() => { + return values?.reduce((sum, { total }) => sum + total, 0) ?? 0; + }, [values]); + + const chartData = useMemo(() => { + if (!propertyName || !values) return null; + return { + labels: values.map(({ value }) => value), + datasets: [ + { + data: values.map(({ total }) => total), + backgroundColor: CHART_COLORS, + borderWidth: 0, + }, + ], + }; + }, [propertyName, values]); + + const tableData = useMemo(() => { + if (!propertyName || !values || propertySum === 0) return []; + return values.map(({ value, total }) => ({ + label: value, + count: total, + percent: 100 * (total / propertySum), + })); + }, [propertyName, values, propertySum]); + + return ( + <LoadingPanel + isLoading={isLoading} + isFetching={isFetching} + data={values} + error={error} + minHeight="300px" + gap="6" + > + {values && ( + <Grid columns="1fr 1fr" gap> + <ListTable title={propertyName} data={tableData} /> + <PieChart type="doughnut" chartData={chartData} /> + </Grid> + )} + </LoadingPanel> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx new file mode 100644 index 0000000..f686b3f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx @@ -0,0 +1,48 @@ +import { type ReactNode, useState } from 'react'; +import { DataGrid } from '@/components/common/DataGrid'; +import { useMessages, useWebsiteEventsQuery } from '@/components/hooks'; +import { FilterButtons } from '@/components/input/FilterButtons'; +import { EventsTable } from './EventsTable'; + +export function EventsDataTable({ + websiteId, +}: { + websiteId?: string; + teamId?: string; + children?: ReactNode; +}) { + const { formatMessage, labels } = useMessages(); + const [view, setView] = useState('all'); + const query = useWebsiteEventsQuery(websiteId, { view }); + + const buttons = [ + { + id: 'all', + label: formatMessage(labels.all), + }, + { + id: 'views', + label: formatMessage(labels.views), + }, + { + id: 'events', + label: formatMessage(labels.events), + }, + ]; + + const renderActions = () => { + return <FilterButtons items={buttons} value={view} onChange={setView} />; + }; + + return ( + <DataGrid + query={query} + allowSearch={true} + autoFocus={false} + allowPaging={true} + renderActions={renderActions} + > + {({ data }) => <EventsTable data={data} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx new file mode 100644 index 0000000..a7ed399 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx @@ -0,0 +1,40 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages } from '@/components/hooks'; +import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { formatLongNumber } from '@/lib/format'; + +export function EventsMetricsBar({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId); + + return ( + <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}> + {data && ( + <MetricsBar> + <MetricCard + value={data?.visitors?.value} + label={formatMessage(labels.visitors)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.visits?.value} + label={formatMessage(labels.visits)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.pageviews?.value} + label={formatMessage(labels.views)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.events?.value} + label={formatMessage(labels.events)} + formatValue={formatLongNumber} + /> + </MetricsBar> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx new file mode 100644 index 0000000..55ec040 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx @@ -0,0 +1,59 @@ +'use client'; +import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { type Key, useState } from 'react'; +import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { EventsChart } from '@/components/metrics/EventsChart'; +import { MetricsTable } from '@/components/metrics/MetricsTable'; +import { getItem, setItem } from '@/lib/storage'; +import { EventProperties } from './EventProperties'; +import { EventsDataTable } from './EventsDataTable'; + +const KEY_NAME = 'umami.events.tab'; + +export function EventsPage({ websiteId }) { + const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); + const { formatMessage, labels } = useMessages(); + + const handleSelect = (value: Key) => { + setItem(KEY_NAME, value); + setTab(value); + }; + + return ( + <Column gap="3"> + <WebsiteControls websiteId={websiteId} /> + <Panel> + <Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}> + <TabList> + <Tab id="chart">{formatMessage(labels.chart)}</Tab> + <Tab id="activity">{formatMessage(labels.activity)}</Tab> + <Tab id="properties">{formatMessage(labels.properties)}</Tab> + </TabList> + <TabPanel id="activity"> + <EventsDataTable websiteId={websiteId} /> + </TabPanel> + <TabPanel id="chart"> + <Column gap="6"> + <Column border="bottom" paddingBottom="6"> + <EventsChart websiteId={websiteId} /> + </Column> + <MetricsTable + websiteId={websiteId} + type="event" + title={formatMessage(labels.event)} + metric={formatMessage(labels.count)} + /> + </Column> + </TabPanel> + <TabPanel id="properties"> + <EventProperties websiteId={websiteId} /> + </TabPanel> + </Tabs> + </Panel> + <SessionModal websiteId={websiteId} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx new file mode 100644 index 0000000..7fb2eb4 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx @@ -0,0 +1,107 @@ +import { + Button, + DataColumn, + DataTable, + type DataTableProps, + Dialog, + DialogTrigger, + Icon, + IconLabel, + Popover, + Row, + Text, +} from '@umami/react-zen'; +import Link from 'next/link'; +import { Avatar } from '@/components/common/Avatar'; +import { DateDistance } from '@/components/common/DateDistance'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useFormat, useMessages, useNavigation } from '@/components/hooks'; +import { Eye, FileText } from '@/components/icons'; +import { EventData } from '@/components/metrics/EventData'; +import { Lightning } from '@/components/svg'; + +export function EventsTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { updateParams } = useNavigation(); + const { formatValue } = useFormat(); + + return ( + <DataTable {...props}> + <DataColumn id="event" label={formatMessage(labels.event)} width="2fr"> + {(row: any) => { + return ( + <Row alignItems="center" wrap="wrap" gap> + <Row> + <IconLabel + icon={row.eventName ? <Lightning /> : <Eye />} + label={formatMessage(row.eventName ? labels.triggeredEvent : labels.viewedPage)} + /> + </Row> + <Text + weight="bold" + style={{ maxWidth: '300px' }} + title={row.eventName || row.urlPath} + truncate + > + {row.eventName || row.urlPath} + </Text> + {row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />} + </Row> + ); + }} + </DataColumn> + <DataColumn id="session" label={formatMessage(labels.session)} width="80px"> + {(row: any) => { + return ( + <Link href={updateParams({ session: row.sessionId })}> + <Avatar seed={row.sessionId} size={32} /> + </Link> + ); + }} + </DataColumn> + <DataColumn id="location" label={formatMessage(labels.location)}> + {(row: any) => ( + <TypeIcon type="country" value={row.country}> + {row.city ? `${row.city}, ` : ''} {formatValue(row.country, 'country')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="browser" label={formatMessage(labels.browser)} width="140px"> + {(row: any) => ( + <TypeIcon type="browser" value={row.browser}> + {formatValue(row.browser, 'browser')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="device" label={formatMessage(labels.device)} width="120px"> + {(row: any) => ( + <TypeIcon type="device" value={row.device}> + {formatValue(row.device, 'device')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="created" width="160px" align="end"> + {(row: any) => <DateDistance date={new Date(row.createdAt)} />} + </DataColumn> + </DataTable> + ); +} + +const PropertiesButton = props => { + return ( + <DialogTrigger> + <Button variant="quiet"> + <Row alignItems="center" gap> + <Icon> + <FileText /> + </Icon> + </Row> + </Button> + <Popover placement="right"> + <Dialog> + <EventData {...props} /> + </Dialog> + </Popover> + </DialogTrigger> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/events/page.tsx b/src/app/(main)/websites/[websiteId]/events/page.tsx new file mode 100644 index 0000000..d77ba3b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { EventsPage } from './EventsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <EventsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Events', +}; |